From 8de0918c33cb5d82f320289f856b94f2662c7ecd Mon Sep 17 00:00:00 2001 From: Hiro Date: Sun, 21 Dec 2025 23:58:32 -0600 Subject: [PATCH 1/2] Account download and deletion --- packages/api/src/user/user.controller.ts | 36 ++- packages/api/src/user/user.service.ts | 73 ++++++ packages/app/src/hooks/user.ts | 72 +++++- .../app/src/pages/(Home)/NameProvider.tsx | 8 +- packages/app/src/pages/Settings.tsx | 233 +++++++++++++++++- 5 files changed, 406 insertions(+), 16 deletions(-) diff --git a/packages/api/src/user/user.controller.ts b/packages/api/src/user/user.controller.ts index 69c79d6..34da686 100644 --- a/packages/api/src/user/user.controller.ts +++ b/packages/api/src/user/user.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Inject, Middleware, Patch } from "@outwalk/firefly"; +import { Controller, Get, Inject, Middleware, Patch, Post } from "@outwalk/firefly"; import { UserService } from "./user.service"; import { session } from "@/_middleware/session"; import { Request } from "express"; import { User } from "./user.entity"; +import { BadRequest } from "@outwalk/firefly/errors"; @Controller() @Middleware(session) @@ -18,7 +19,38 @@ export class UserController { @Patch() async updateName({ session, body }: Request): Promise { - return this.userService.updateUser(session.user.id, body); + const { first, last, email } = body ?? {}; + + if (!first && !last && !email) { + throw new BadRequest("No profile fields provided."); + } + + if (email && await this.userService.emailInUse(email, session.user.id)) { + throw new BadRequest("Email already in use."); + } + + return this.userService.updateUser(session.user.id, { first, last, email }); + } + + @Patch("/password") + async changePassword({ session, body }: Request): Promise { + const { currentPassword, newPassword } = body ?? {}; + + if (!currentPassword || !newPassword) { + throw new BadRequest("Current and new passwords are required."); + } + + return this.userService.changePassword(session.user.id, currentPassword, newPassword); + } + + @Get("/export") + async exportData({ session }: Request): Promise<{ user: User | null; tasks: any[] }> { + return this.userService.exportUserData(session.user.id); + } + + @Post("/delete") + async deleteData({ session }: Request): Promise<{ deletedUser: boolean; removedFromTasks: number; deletedTasks: number }> { + return this.userService.deleteUserData(session.user.id); } @Get("/synced") diff --git a/packages/api/src/user/user.service.ts b/packages/api/src/user/user.service.ts index 640e7b8..d773a4f 100644 --- a/packages/api/src/user/user.service.ts +++ b/packages/api/src/user/user.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@outwalk/firefly"; import { BadRequest } from "@outwalk/firefly/errors"; import { User } from "./user.entity"; import bcrypt from "bcrypt"; +import { Task } from "@/task/task.entity"; @Injectable() export class UserService { @@ -26,9 +27,81 @@ export class UserService { return User.findByIdAndUpdate(id, data).lean().exec(); } + async emailInUse(email: string, excludeId?: string): Promise { + const query: any = { email }; + if (excludeId) query._id = { $ne: excludeId }; + return Boolean(await User.exists(query).exec()); + } + async validatePassword(id: string, password: string): Promise { const user = await User.findById(id).select("password").lean().exec(); if (!user?.password) return false; return bcrypt.compare(password, user.password); } + + async changePassword(id: string, current: string, next: string): Promise { + const valid = await this.validatePassword(id, current); + if (!valid) { + throw new BadRequest("Current password is incorrect."); + } + + return this.updateUser(id, { password: next }); + } + + private sanitizeUser(user: any) { + if (!user) return null; + const { first, last, email, createdAt, updatedAt } = user; + return { first, last, email, createdAt, updatedAt }; + } + + private sanitizeTask(task: any) { + if (!task) return null; + const { + title, + description, + date, + done, + repeater, + reminder, + type, + accordion, + priority, + group, + tags, + id + } = task; + return { + id, + title, + description, + date, + done, + repeater, + reminder, + type, + accordion, + priority, + group, + tags + }; + } + + async exportUserData(id: string): Promise<{ user: any; tasks: any[] }> { + const user = this.sanitizeUser(await this.getUserById(id)); + const tasksRaw = await Task.find({ users: id }).lean().exec(); + const tasks = tasksRaw.map((t) => this.sanitizeTask(t)).filter(Boolean); + return { user, tasks }; + } + + async deleteUserData(id: string): Promise<{ deletedUser: boolean; removedFromTasks: number; deletedTasks: number }> { + const pullResult = await Task.updateMany({ users: id }, { $pull: { users: id } }).exec(); + const cleanup = await Task.deleteMany({ users: { $size: 0 } }).exec(); + const deleted = await User.findByIdAndDelete(id).lean().exec(); + + return { + deletedUser: Boolean(deleted), + removedFromTasks: pullResult.modifiedCount ?? 0, + deletedTasks: cleanup.deletedCount ?? 0 + }; + } } diff --git a/packages/app/src/hooks/user.ts b/packages/app/src/hooks/user.ts index 60e47b1..b10d300 100644 --- a/packages/app/src/hooks/user.ts +++ b/packages/app/src/hooks/user.ts @@ -1,4 +1,4 @@ -import { QueryClient, UseQueryResult, useQuery } from "@tanstack/react-query"; +import { QueryClient, UseMutationResult, UseQueryResult, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchData } from "@/utils/data"; import { User } from "@backend/user/user.entity"; @@ -7,13 +7,20 @@ async function getUser(): Promise { return await response.json(); } -export async function updateName(first: string, last: string): Promise { - await fetchData("/user/name", { +export async function updateProfile(data: Partial>): Promise { + const response = await fetchData("/user", { method: "PATCH", - body: { - first, last - } - }) + body: data + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err?.message || "Unable to update profile"); + } +} + +export async function updateName(first: string, last: string): Promise { + return updateProfile({ first, last }); } export function useUser(): UseQueryResult { @@ -24,10 +31,59 @@ export function useUser(): UseQueryResult { }); } +export function useUpdateProfile(): UseMutationResult>> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateProfile, + onSuccess: () => reloadUser(queryClient) + }); +} + +export function useChangePassword(): UseMutationResult { + return useMutation({ + mutationFn: async (body) => { + const response = await fetchData("/user/password", { + method: "PATCH", + body + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err?.message || "Unable to update password"); + } + } + }); +} + +export async function exportUserData(): Promise<{ user: User | null; tasks: any[] }> { + const response = await fetchData("/user/export", {}); + return await response.json(); +} + +export function useExportUserData(): UseMutationResult<{ user: User | null; tasks: any[] }> { + return useMutation({ + mutationFn: exportUserData + }); +} + +export async function requestUserDeletion(): Promise<{ deletedUser: boolean; removedFromTasks: number; deletedTasks: number }> { + const response = await fetchData("/user/delete", { + method: "POST" + }); + return await response.json(); +} + +export function useRequestUserDeletion(): UseMutationResult<{ deletedUser: boolean; removedFromTasks: number; deletedTasks: number }> { + return useMutation({ + mutationFn: requestUserDeletion + }); +} + export function reloadUser(queryClient: QueryClient): Promise { return queryClient.invalidateQueries({ queryKey: ["user"] }); } export function reloadToken(queryClient: QueryClient): Promise { return queryClient.invalidateQueries({ queryKey: ["token"] }); -} \ No newline at end of file +} diff --git a/packages/app/src/pages/(Home)/NameProvider.tsx b/packages/app/src/pages/(Home)/NameProvider.tsx index 4cc7575..3ac2e72 100644 --- a/packages/app/src/pages/(Home)/NameProvider.tsx +++ b/packages/app/src/pages/(Home)/NameProvider.tsx @@ -10,10 +10,8 @@ export default function NameProvider() { const first = e.target[0].value; const last = e.target[1].value; - const resp = await updateName(first, last); - - if(resp?.email) - navigate(0); + await updateName(first, last); + navigate(0); } return ( @@ -31,4 +29,4 @@ export default function NameProvider() { ) -} \ No newline at end of file +} diff --git a/packages/app/src/pages/Settings.tsx b/packages/app/src/pages/Settings.tsx index 960c7b8..ce9eb04 100644 --- a/packages/app/src/pages/Settings.tsx +++ b/packages/app/src/pages/Settings.tsx @@ -8,7 +8,7 @@ import { Capacitor } from "@capacitor/core"; import { Settings, getSettings, setSettings } from "@/hooks/settings"; import { PendingLocalNotificationSchema } from "@capacitor/local-notifications"; -import { useEffect, useState } from "react"; +import { useEffect, useState, FormEvent } from "react"; import DailyNotifications from "./(Settings)/DailyNotifications"; import UserLogin from "./(Settings)/UserLogin"; import { Logger } from "@/utils/logger"; @@ -19,6 +19,8 @@ import xIcon from "@/assets/social_icons/x.svg"; import instagramIcon from "@/assets/social_icons/instagram.svg"; import facebookIcon from "@/assets/social_icons/facebook.svg"; import { useApp } from "@/hooks/app"; +import { useChangePassword, useExportUserData, useRequestUserDeletion, useUpdateProfile, useUser } from "@/hooks/user"; +import { fetchData } from "@/utils/data"; export default function SettingsPage() { const [tempSettings, setTempSettings] = useState({}); @@ -27,6 +29,18 @@ export default function SettingsPage() { const [cleanupInterval, setCleanupInterval] = useState("30"); const [cleanupStatus, setCleanupStatus] = useState(""); const [appState, setAppState] = useApp(); + const user = useUser(); + const updateProfile = useUpdateProfile(); + const changePassword = useChangePassword(); + const exportUserData = useExportUserData(); + const requestUserDeletion = useRequestUserDeletion(); + const [profileForm, setProfileForm] = useState({ first: "", last: "", email: "" }); + const [profileMessage, setProfileMessage] = useState(""); + const [passwordForm, setPasswordForm] = useState({ current: "", next: "", confirm: "" }); + const [passwordMessage, setPasswordMessage] = useState(""); + const [dataMessage, setDataMessage] = useState(""); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteInput, setDeleteInput] = useState(""); useEffect(() => { getSettings().then(async (tempSettings) => { @@ -34,6 +48,16 @@ export default function SettingsPage() { }); }, []); + useEffect(() => { + if (user.isSuccess && user.data) { + setProfileForm({ + first: user.data.first || "", + last: user.data.last || "", + email: user.data.email || "" + }); + } + }, [user.isSuccess, user.data]); + const UpdateSettings = async (newValue: object) => { const settings: Settings = { ...tempSettings, ...newValue }; @@ -139,6 +163,73 @@ export default function SettingsPage() { setAppState({ ...appState, theme: next }); }; + const handleProfileSubmit = async (e: FormEvent) => { + e.preventDefault(); + setProfileMessage(""); + try { + await updateProfile.mutateAsync(profileForm); + setProfileMessage("Profile updated."); + } catch (err: any) { + setProfileMessage(err?.message || "Unable to update profile."); + } + }; + + const handlePasswordSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (passwordForm.next !== passwordForm.confirm) { + setPasswordMessage("New passwords do not match."); + return; + } + setPasswordMessage(""); + try { + await changePassword.mutateAsync({ + currentPassword: passwordForm.current, + newPassword: passwordForm.next + }); + setPasswordMessage("Password updated."); + setPasswordForm({ current: "", next: "", confirm: "" }); + } catch (err: any) { + setPasswordMessage(err?.message || "Unable to update password."); + } + }; + + const handleDownloadData = async () => { + setDataMessage(""); + try { + const data = await exportUserData.mutateAsync(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "sequenced-account-data.json"; + link.click(); + URL.revokeObjectURL(url); + setDataMessage("Download started."); + } catch (err: any) { + setDataMessage(err?.message || "Unable to download data."); + } + }; + + const handleDeleteData = async () => { + setDataMessage(""); + if (deleteInput.trim().toUpperCase() !== "DELETE") { + setDataMessage("Type DELETE to confirm."); + return; + } + try { + const resp = await requestUserDeletion.mutateAsync(); + setDataMessage( + `Deletion requested. Removed from ${resp.removedFromTasks} tasks; deleted ${resp.deletedTasks} tasks. This cannot be undone.` + ); + setShowDeleteConfirm(false); + setDeleteInput(""); + await fetchData("/auth/logout", { method: "POST" }); + window.location.href = "/auth"; + } catch (err: any) { + setDataMessage(err?.message || "Unable to process deletion."); + } + }; + const TestDaily = async () => { const fireAt = new Date(Date.now() + 3000); await scheduleNotification({ @@ -177,6 +268,146 @@ export default function SettingsPage() {
+
+
+
+

Profile

+

Manage your account details and privacy.

+
+ {user.isLoading && Loading...} +
+
+ + + +
+ + {profileMessage && {profileMessage}} +
+
+
+

Change password

+
+ + + +
+ + {passwordMessage && {passwordMessage}} +
+
+
+
+

Privacy

+
+ + + {showDeleteConfirm && ( +
+

+ This will permanently delete all of your Sequenced data (tasks, tags, account). There is no way to recover it. +

+
+ + setDeleteInput(e.target.value)} + placeholder="DELETE" + className="w-full rounded-lg border border-red-300 bg-white px-2 py-2 text-sm shadow-inner focus:border-red-500 focus:outline-none dark:border-red-500/60 dark:bg-slate-900 dark:text-white" + /> + +
+
+ )} + {dataMessage && {dataMessage}} +
+
+
+
From 72f9542ca1f1e78988edfbc9e338f66b2f85313e Mon Sep 17 00:00:00 2001 From: Hiro Date: Mon, 22 Dec 2025 00:10:35 -0600 Subject: [PATCH 2/2] Notification Settings Update --- packages/app/src/pages/Settings.tsx | 6 ++- packages/app/src/utils/notifs.ts | 76 +++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/app/src/pages/Settings.tsx b/packages/app/src/pages/Settings.tsx index ce9eb04..fbe3429 100644 --- a/packages/app/src/pages/Settings.tsx +++ b/packages/app/src/pages/Settings.tsx @@ -20,6 +20,7 @@ import instagramIcon from "@/assets/social_icons/instagram.svg"; import facebookIcon from "@/assets/social_icons/facebook.svg"; import { useApp } from "@/hooks/app"; import { useChangePassword, useExportUserData, useRequestUserDeletion, useUpdateProfile, useUser } from "@/hooks/user"; +import { getTodayNotificationBody } from "@/utils/notifs"; import { fetchData } from "@/utils/data"; export default function SettingsPage() { @@ -231,11 +232,12 @@ export default function SettingsPage() { }; const TestDaily = async () => { - const fireAt = new Date(Date.now() + 3000); + const fireAt = new Date(Date.now()); + const body = await getTodayNotificationBody(); await scheduleNotification({ id: Math.floor(Math.random() * 2147483647), title: "Sequenced: Test Reminder", - body: "This is a test daily notification.", + body, schedule: { at: fireAt }, }); Logger.log("Scheduled test notification for", fireAt.toISOString()); diff --git a/packages/app/src/utils/notifs.ts b/packages/app/src/utils/notifs.ts index a4703ec..20c78d5 100644 --- a/packages/app/src/utils/notifs.ts +++ b/packages/app/src/utils/notifs.ts @@ -6,8 +6,40 @@ import { PermissionStatus, ScheduleResult, } from "@capacitor/local-notifications"; +import { Capacitor } from "@capacitor/core"; import { getSettings, setSettings } from "@/hooks/settings"; import { Logger } from "./logger"; +import { fetchData } from "./data"; + +const FALLBACK_BODY = "Stay on track—open Sequenced to see what's due today."; + +const isWeb = () => Capacitor.getPlatform() === "web"; +const supportsWebNotifications = () => typeof Notification !== "undefined"; + +export async function getTodayNotificationBody(): Promise { + try { + const response = await fetchData("/task/today", {}); + if (!response.ok) throw new Error("Failed to fetch tasks"); + const tasks = await response.json(); + const titles: string[] = Array.isArray(tasks) + ? tasks + .map((t) => (typeof t?.title === "string" ? t.title.trim() : "")) + .filter((t) => t.length > 0) + : []; + + if (titles.length === 0) { + return "Nothing due today. Set a plan and stay ahead."; + } + + const preview = titles.slice(0, 3).join(", "); + const more = titles.length - 3; + const suffix = more > 0 ? ` (+${more} more)` : ""; + return `Due today: ${preview}${suffix}`; + } catch (err) { + Logger.logWarning(`Unable to build daily notification: ${String(err)}`); + return FALLBACK_BODY; + } +} /* Checks if a user has been reminded */ export async function hasRemindedToday(): Promise { @@ -48,10 +80,12 @@ export async function setDailyReminders(hour?: number, minute?: number) { const id = new Date().getTime(); + const body = await getTodayNotificationBody(); + await scheduleNotification({ title: "Sequenced: ADHD Manager", id, - body: "Don't forget to check your tasks!", + body, schedule: { at: timeBuilder, every: "day", @@ -102,11 +136,19 @@ export async function setNotificationConfig() { /* Checks if system can send notifications */ export async function checkPermissions(): Promise { + if (isWeb() && supportsWebNotifications()) { + const state = await Notification.requestPermission(); + return { display: state } as PermissionStatus; + } return await LocalNotifications.checkPermissions(); } /* Requests the ability to request permissions from the user */ export async function requestPermissions(): Promise { + if (isWeb() && supportsWebNotifications()) { + const state = await Notification.requestPermission(); + return { display: state } as PermissionStatus; + } return await LocalNotifications.requestPermissions(); } @@ -118,17 +160,33 @@ export async function scheduleNotification( const checked = await checkPermissions(); - if (checked.display == "granted") { - const notif = await LocalNotifications.schedule({ - notifications: [...options], - }); - - return notif; - } else { + if (checked.display !== "granted") { Logger.logWarning(`Unable to send notification: Missing Permissions`); + return undefined; + } + + if (isWeb() && supportsWebNotifications()) { + options.forEach((opt) => { + const delay = + opt.schedule?.at instanceof Date + ? Math.max(0, opt.schedule.at.getTime() - Date.now()) + : 0; + window.setTimeout(() => { + try { + new Notification(opt.title || "Sequenced", { body: opt.body }); + } catch (err) { + Logger.logWarning(`Web notification failed: ${String(err)}`); + } + }, delay); + }); + return undefined; } - return undefined; + const notif = await LocalNotifications.schedule({ + notifications: [...options], + }); + + return notif; } export async function cancelNotifications(