Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions packages/api/src/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -18,7 +19,38 @@ export class UserController {

@Patch()
async updateName({ session, body }: Request): Promise<User | null> {
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<User | null> {
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")
Expand Down
73 changes: 73 additions & 0 deletions packages/api/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,9 +27,81 @@ export class UserService {
return User.findByIdAndUpdate(id, data).lean<User>().exec();
}

async emailInUse(email: string, excludeId?: string): Promise<boolean> {
const query: any = { email };
if (excludeId) query._id = { $ne: excludeId };
return Boolean(await User.exists(query).exec());
}

async validatePassword(id: string, password: string): Promise<boolean> {
const user = await User.findById(id).select("password").lean<User>().exec();
if (!user?.password) return false;
return bcrypt.compare(password, user.password);
}

async changePassword(id: string, current: string, next: string): Promise<User | null> {
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<User>().exec();

return {
deletedUser: Boolean(deleted),
removedFromTasks: pullResult.modifiedCount ?? 0,
deletedTasks: cleanup.deletedCount ?? 0
};
}
}
72 changes: 64 additions & 8 deletions packages/app/src/hooks/user.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -7,13 +7,20 @@ async function getUser(): Promise<User> {
return await response.json();
}

export async function updateName(first: string, last: string): Promise<void> {
await fetchData("/user/name", {
export async function updateProfile(data: Partial<Pick<User, "first" | "last" | "email">>): Promise<void> {
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<void> {
return updateProfile({ first, last });
}

export function useUser(): UseQueryResult<User> {
Expand All @@ -24,10 +31,59 @@ export function useUser(): UseQueryResult<User> {
});
}

export function useUpdateProfile(): UseMutationResult<void, Error, Partial<Pick<User, "first" | "last" | "email">>> {
const queryClient = useQueryClient();

return useMutation({
mutationFn: updateProfile,
onSuccess: () => reloadUser(queryClient)
});
}

export function useChangePassword(): UseMutationResult<void, Error, { currentPassword: string; newPassword: string }> {
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<void> {
return queryClient.invalidateQueries({ queryKey: ["user"] });
}

export function reloadToken(queryClient: QueryClient): Promise<void> {
return queryClient.invalidateQueries({ queryKey: ["token"] });
}
}
8 changes: 3 additions & 5 deletions packages/app/src/pages/(Home)/NameProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -31,4 +29,4 @@ export default function NameProvider() {
</form>
</div>
)
}
}
Loading